Skip to content

fix(api): price cached input tokens at the cached rate - #5965

Merged
mmabrouk merged 2 commits into
Agenta-AI:release/v0.112.3from
WhoamiI00:fix/cached-input-token-cost
Aug 20, 2026
Merged

fix(api): price cached input tokens at the cached rate#5965
mmabrouk merged 2 commits into
Agenta-AI:release/v0.112.3from
WhoamiI00:fix/cached-input-token-cost

Conversation

@WhoamiI00

Copy link
Copy Markdown
Contributor

Summary

Traced cost bills every prompt token at the full input rate, including the slice a provider served from its cache. On the case in #5711 — a 25,978-token Gemini prompt of which 24,540 were cached — that reports $0.007793 of prompt cost where $0.001168 is correct: 6.67x too high. It runs in OSS as well as cloud, and it is worst on agent workloads, which replay a long prefix on every call.

calculate_costs read only prompt and completion out of tokens.incremental and called litellm's cost_per_token with those alone — even though that function accepts cache_read_input_tokens and litellm's price map carries a separate, much lower cached rate (a tenth of input, for Gemini Flash).

One correction to the issue

The issue says the cached count is never recorded. That is true of the SDK path but not of the OTLP path: logfire_adapter.py already maps gen_ai.usage.cache_read.input_tokensag.metrics.unit.tokens.cache_read, and span_data_builders.py rewrites unit.tokens.*tokens.incremental.*, so it does land in ag.metrics.tokens.incremental.cache_read. The runner already emits that attribute. The count was being recorded and then dropped on the floor at the one place that prices tokens.

Three things this has to get right

1. The cached count is a subset of prompt_tokens, not an addition to it. litellm's convention is that prompt_tokens already includes the cached slice (it normalizes Anthropic-style usage, where the input count excludes them, on the way in). So the count is passed alongside the prompt total, never deducted from it — deducting would understate cost instead of overstating it.

2. It must arrive as an int. This one is not obvious and it is the whole fix. litellm reads the slice back off Usage.prompt_tokens_details.cached_tokens, and its Usage model only derives that wrapper from an int:

Usage(prompt_tokens=25978, cache_read_input_tokens=24540)     # int
  -> prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=24540)

Usage(prompt_tokens=25978, cache_read_input_tokens=24540.0)   # float
  -> prompt_tokens_details=None

Agenta stores token metrics as floats, so passing the value through as-is leaves prompt_tokens_details at None and every token is billed at the full input rate again — silently, with no exception to catch. I had this wrong at first; the end-to-end demo below is what caught it, since the mocked unit tests happily showed the kwarg being passed.

3. The adapters disagree on the field name. logfire_adapter writes cache_read (matching what the runner emits), vercelai_adapter writes cached (from ai.usage.cachedInputTokens). Both are read, or the cost is right for one integration and overstated for the other.

Backward compatibility

The kwarg is passed only when the count is non-zero, so a span with no caching calls exactly the signature it always did. The SDK pins litellm>=1,<2, and on a 1.x old enough to lack the parameter an unconditional kwarg would raise TypeError straight into the existing bare except — dropping costs for every span, not just cached ones.

SDK side

The litellm handler never recorded the cached count, so cache_read could not reach the API for SDK-traced calls. It now reads prompt_tokens_details.cached_tokens (OpenAI and Google both report it there) with a fallback to a flat cache_read_input_tokens (Anthropic-style). The extraction was duplicated between the sync and async paths; it is now one helper.

Closes #5711

Testing

Verified locally

Run on Linux (python:3.11-slim container) because litellm 1.92.0 ships manylinux wheels only.

  • The demo below is real captured output — real litellm 1.92.0 price map, real calculate_costs, no mocks. The BEFORE line is literally the call the old code made.
  • pytest oss/tests/pytest/unit/tracing (api) — 92 passed.
  • pytest oss/tests/pytest/unit (SDK) — 2022 passed, 4 skipped, 10 xfailed.
  • python run-tests.py --layer unit (api) — 2210 passed, 4 failed. The 4 failures are in test_web_entrypoint_email_env.py (SMTP entrypoint config) and are pre-existing: they fail identically on a clean checkout of main with this branch's changes stashed. Nothing this PR touches is involved.
  • ruff format --check and ruff check on all four changed files — clean.
  • Confirmed the new tests fail without the fix: reverting trees.py alone fails exactly the three cached-behaviour tests, while the two "signature unchanged" tests keep passing in both directions.

Added or updated tests

api/oss/tests/pytest/unit/tracing/utils/test_trees.py — the _span helper takes an optional cached count under a configurable key, plus:

  • test_calculate_costs_passes_cached_tokens_to_the_pricer — parametrized over cache_read and cached, asserting the count is forwarded and the prompt total is passed through untouched.
  • test_calculate_costs_sends_the_cached_count_as_an_int — the float trap in point 2. Without the coercion the fix silently does nothing, so this is the test that matters most.
  • test_calculate_costs_omits_cache_kwarg_when_nothing_was_cached — calls a pricer whose signature accepts nothing else, so a regression to an unconditional kwarg fails loudly.
  • test_calculate_costs_ignores_a_zero_cached_count — an explicit zero is a cache miss.
  • test_calculate_costs_bills_cached_tokens_below_fresh_input — end-to-end ratio against a pricer modelling litellm's published contract.

sdks/python/oss/tests/pytest/unit/test_litellm_token_usage.py (new) — 8 cases over _extract_token_usage: OpenAI-style objects, dict-shaped usage, the flat Anthropic-style field, precedence when both are present, absent/zero/null cache details, and a response with no usage object.

QA follow-up

Worth a maintainer's eye on two things I deliberately left alone:

  • Cumulation. cumulate_tokens has a hardcoded prompt/completion/total shape, so cache_read does not roll up to parent spans. Costs are correct either way (they cumulate from the corrected per-span costs); this only means the cached token count is not visible in tokens.cumulative. Extending it changes the rollup shape the frontend reads, which felt out of scope here.
  • Field-name drift. Reading both cache_read and cached fixes the symptom. Normalizing vercelai_adapter to emit cache_read would fix the cause, but it changes ingest behaviour for existing spans, so I left it.

Also worth noting: cache_creation is recorded by logfire_adapter and still not priced. Cache writes bill above the normal input rate on some providers, so that is a separate, smaller understatement — happy to follow up if you want it in scope.

Demo

Backend-only change, so the demo is captured terminal output rather than a UI recording: the real calculate_costs against the real litellm price map, before and after, plus a check that an uncached span is unchanged.

before/after prompt cost for a cached Gemini call, real litellm pricing

Checklist

  • I have included a video or screen recording for UI changes, or marked Demo as N/A
  • Relevant tests pass locally
  • Relevant linting and formatting pass locally
  • I have signed the CLA, or I will sign it when the bot prompts me

Traced cost billed every prompt token at the full input rate, including the
slice a provider served from its cache. On the case in Agenta-AI#5711 -- a 25,978-token
Gemini prompt with 24,540 cached -- that reports $0.007793 of prompt cost where
$0.001168 is correct, 6.67x too high. It is worst on agent workloads, which
replay a long prefix on every call, and it runs in OSS as well as cloud.

`calculate_costs` read only `prompt` and `completion` out of
`tokens.incremental` and called litellm's `cost_per_token` with those alone,
even though litellm accepts `cache_read_input_tokens` and its price map carries
a separate, much lower cached rate (a tenth of input, for Gemini Flash).

Read the cached count and forward it. Three things this has to get right:

- The count is a SUBSET of `prompt_tokens`, not an addition to it. litellm's
  convention is that `prompt_tokens` already includes the cached slice, and it
  normalizes Anthropic-style usage on the way in, so it is passed alongside the
  prompt total rather than deducted from it. Deducting would understate cost.

- It must arrive as an int. litellm reads the slice back off
  `Usage.prompt_tokens_details.cached_tokens`, and its `Usage` model only
  derives that wrapper from an int -- given the float this metric is stored as,
  `prompt_tokens_details` is None and every token is billed at the full rate
  again, silently. Without the coercion the rest of this fix is a no-op.

- The adapters disagree on the field name: `logfire_adapter` writes
  `cache_read` (matching what the runner emits), `vercelai_adapter` writes
  `cached`. Both are read, or the cost is right for one integration only.

The kwarg is passed only when the count is non-zero, so an uncached span calls
exactly the signature it always did. The SDK pins `litellm>=1,<2`, and on a 1.x
without the parameter an unconditional kwarg would raise TypeError into the
bare `except`, dropping costs for every span rather than just cached ones.

On the SDK side the litellm handler never recorded the count at all, so
`cache_read` could not reach the API for SDK-traced calls. It now reads
`prompt_tokens_details.cached_tokens` (OpenAI and Google) with a fallback to a
flat `cache_read_input_tokens` (Anthropic-style). The extraction was duplicated
between the sync and async paths and is now one helper.

Closes Agenta-AI#5711
@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Aug 12, 2026
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

@WhoamiI00 is attempting to deploy a commit to the agenta projects Team on Vercel.

A member of the Team first needs to authorize it.

@dosubot dosubot Bot added python Pull requests that update Python code tests labels Aug 12, 2026
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes
    • Improved LiteLLM usage tracking for cached prompt tokens across supported response formats.
    • Cached tokens are now recognized consistently in synchronous and asynchronous traces.
    • Cost calculations now account for cached input tokens, improving billing accuracy.
    • Added compatibility for both cache_read and cached token fields.
    • Supports dictionary and object-based usage responses, including nested provider details.
    • Preserved existing behavior when cached-token data is missing, zero, invalid, or negative.

Walkthrough

The SDK extracts cached prompt tokens from LiteLLM responses and records them in tracing metrics. API cost calculation recognizes cache_read and cached, validates values, and passes valid counts to LiteLLM.

Changes

Cached prompt token billing

Layer / File(s) Summary
LiteLLM token extraction and tracing
sdks/python/agenta/sdk/litellm/litellm.py, sdks/python/oss/tests/pytest/unit/test_litellm_token_usage.py
Shared extraction handles object and dictionary usage payloads, nested cache details, provider-specific fields, and synchronous or asynchronous tracing.
API cost calculation and validation
api/oss/src/core/tracing/utils/trees.py, api/oss/tests/pytest/unit/tracing/utils/test_trees.py
Cost calculation accepts both cached-token aliases, forwards valid positive integer counts to LiteLLM, preserves legacy calls when appropriate, and tests reduced cached-input billing.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: 🔵 Low · up to 1c7a9

The PR correctly applies cached-token pricing, but non-finite cached-token values can still cause recalculated costs to be dropped instead of recorded. This is a bounded billing-accuracy risk that is mergeable with explicit owner awareness or a follow-up fix.

Sequence Diagram(s)

sequenceDiagram
  participant LiteLLM
  participant LiteLLMTracing
  participant APITracing
  participant CostPricer
  LiteLLM->>LiteLLMTracing: usage response with cached prompt tokens
  LiteLLMTracing->>APITracing: cache_read token metric
  APITracing->>CostPricer: cached input token count
  CostPricer-->>APITracing: calculated cost
Loading

Possibly related issues

  • Agenta-AI/agenta issue 5540 — Covers cached-token extraction and cache-aware LiteLLM pricing in the same tracing path.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 46.15% which is insufficient. The required threshold is 60.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: pricing cached input tokens at the cached rate.
Description check ✅ Passed The description directly explains the cached-token pricing defect, implementation, compatibility decisions, and tests.
Linked Issues check ✅ Passed The changes satisfy issue #5711 by extracting, recording, and pricing cached input tokens while preserving prompt totals.
Out of Scope Changes check ✅ Passed All code changes support cached-token extraction and pricing objectives; the added tests are directly related to issue #5711.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c112c05a-d634-4dbd-9299-a6d7091cfca5

📥 Commits

Reviewing files that changed from the base of the PR and between e3f57d1 and ff4d841.

⛔ Files ignored due to path filters (1)
  • .github/pr-assets/5711-cached-token-cost.png is excluded by !**/*.png
📒 Files selected for processing (4)
  • api/oss/src/core/tracing/utils/trees.py
  • api/oss/tests/pytest/unit/tracing/utils/test_trees.py
  • sdks/python/agenta/sdk/litellm/litellm.py
  • sdks/python/oss/tests/pytest/unit/test_litellm_token_usage.py

Comment thread api/oss/src/core/tracing/utils/trees.py
@mmabrouk
mmabrouk changed the base branch from main to release/v0.112.3 August 20, 2026 15:27

@mmabrouk mmabrouk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @WhoamiI00 great work!

@dosubot dosubot Bot added the lgtm This PR has been approved by a maintainer label Aug 20, 2026
@mmabrouk

Copy link
Copy Markdown
Member

@all-contributors please add @WhoamiI00 for bug fix

@allcontributors

Copy link
Copy Markdown
Contributor

@mmabrouk

I've put up a pull request to add @WhoamiI00! 🎉

…cing

A truthy non-numeric cache field from a foreign OTLP source would reach
int() inside the try, raise, and be swallowed by the bare except -- dropping
the span's entire cost, where before the cached-rate fix such garbage was
simply ignored and prompt/completion were still priced. A negative count is
truthy too and would reach the pricer's fresh-minus-cached arithmetic.
The selection now accepts only real positive numbers, so anything else
degrades to the legacy no-cache call.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
api/oss/src/core/tracing/utils/trees.py (1)

609-643: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Reject non-finite cached-token values before conversion.

float("inf") passes the predicate, and int(cache_read_tokens) raises OverflowError. The handler suppresses the error before cost_per_token runs, so the span receives no recalculated cost. Add math.isfinite(value) and a regression test for the legacy pricing call.

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 11ea64a8-7e5d-446d-9f08-bc989103a9ee

📥 Commits

Reviewing files that changed from the base of the PR and between ff4d841 and 1c7a9ce.

📒 Files selected for processing (2)
  • api/oss/src/core/tracing/utils/trees.py
  • api/oss/tests/pytest/unit/tracing/utils/test_trees.py

Included review availability: Your plan provides up to 8 included reviews per hour; 3 remain after this review.

@mmabrouk
mmabrouk merged commit cb56be3 into Agenta-AI:release/v0.112.3 Aug 20, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lgtm This PR has been approved by a maintainer python Pull requests that update Python code size:L This PR changes 100-499 lines, ignoring generated files. tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(api): traced cost ignores cached input tokens and overstates cost by up to 6.6x

2 participants